[READ-ONLY] a fast, modern browser for the npm registry
at main 493 lines 14 kB view raw
1<script setup lang="ts"> 2import { setResponseHeader } from 'h3' 3import type { DocsResponse } from '#shared/types' 4import { assertValidPackageName, fetchLatestVersion } from '#shared/utils/npm' 5 6definePageMeta({ 7 name: 'docs', 8 path: '/package-docs/:path+', 9 alias: ['/package/docs/:path+', '/docs/:path+'], 10}) 11 12const route = useRoute('docs') 13const router = useRouter() 14 15const parsedRoute = computed(() => { 16 const segments = route.params.path?.filter(Boolean) 17 const vIndex = segments.indexOf('v') 18 19 if (vIndex === -1 || vIndex >= segments.length - 1) { 20 return { 21 packageName: segments.join('/'), 22 version: null as string | null, 23 } 24 } 25 26 return { 27 packageName: segments.slice(0, vIndex).join('/'), 28 version: segments.slice(vIndex + 1).join('/'), 29 } 30}) 31 32const packageName = computed(() => parsedRoute.value.packageName) 33const requestedVersion = computed(() => parsedRoute.value.version) 34 35// Validate package name on server-side for early error detection 36if (import.meta.server && packageName.value) { 37 assertValidPackageName(packageName.value) 38} 39 40const { data: pkg } = usePackage(packageName) 41 42const latestVersion = computed(() => pkg.value?.['dist-tags']?.latest ?? null) 43 44if (import.meta.server && !requestedVersion.value && packageName.value) { 45 const app = useNuxtApp() 46 const version = await fetchLatestVersion(packageName.value) 47 if (version) { 48 setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache') 49 const pathSegments = [...packageName.value.split('/'), 'v', version] 50 app.runWithContext(() => 51 navigateTo( 52 { name: 'docs', params: { path: pathSegments as [string, ...string[]] } }, 53 { redirectCode: 302 }, 54 ), 55 ) 56 } 57} 58 59watch( 60 [requestedVersion, latestVersion, packageName], 61 ([version, latest, name]) => { 62 if (!version && latest && name) { 63 const pathSegments = [...name.split('/'), 'v', latest] 64 router.replace({ name: 'docs', params: { path: pathSegments as [string, ...string[]] } }) 65 } 66 }, 67 { immediate: true }, 68) 69 70const resolvedVersion = computed(() => requestedVersion.value ?? latestVersion.value) 71 72const docsUrl = computed(() => { 73 if (!packageName.value || !resolvedVersion.value) return null 74 return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` 75}) 76 77const shouldFetch = computed(() => !!docsUrl.value) 78 79const { data: docsData, status: docsStatus } = useLazyFetch<DocsResponse>( 80 () => docsUrl.value ?? '', 81 { 82 watch: [docsUrl], 83 immediate: shouldFetch.value, 84 default: () => ({ 85 package: packageName.value, 86 version: resolvedVersion.value ?? '', 87 html: '', 88 toc: null, 89 status: 'missing' as const, 90 message: 'Docs are not available for this version.', 91 }), 92 }, 93) 94 95const pageTitle = computed(() => { 96 if (!packageName.value) return 'API Docs - npmx' 97 if (!resolvedVersion.value) return `${packageName.value} docs - npmx` 98 return `${packageName.value}@${resolvedVersion.value} docs - npmx` 99}) 100 101useSeoMeta({ 102 title: () => pageTitle.value, 103 ogTitle: () => pageTitle.value, 104 twitterTitle: () => pageTitle.value, 105 description: () => pkg.value?.license ?? '', 106 ogDescription: () => pkg.value?.license ?? '', 107 twitterDescription: () => pkg.value?.license ?? '', 108}) 109 110defineOgImageComponent('Default', { 111 title: () => `${pkg.value?.name ?? 'Package'} - Docs`, 112 description: () => pkg.value?.license ?? '', 113 primaryColor: '#60a5fa', 114}) 115 116const showLoading = computed(() => docsStatus.value === 'pending') 117const showEmptyState = computed(() => docsData.value?.status !== 'ok') 118</script> 119 120<template> 121 <div class="docs-page flex-1 flex flex-col"> 122 <!-- Visually hidden h1 for accessibility --> 123 <h1 class="sr-only">{{ packageName }} API Documentation</h1> 124 125 <!-- Sticky header - positioned below AppHeader --> 126 <header 127 aria-label="Package documentation header" 128 class="docs-header sticky z-10 border-b border-border" 129 > 130 <div class="absolute inset-0 bg-bg/90 backdrop-blur" /> 131 <div class="relative px-4 sm:px-6 lg:px-8 py-4 z-1"> 132 <div class="flex items-center justify-between gap-4"> 133 <div class="flex items-center gap-3 min-w-0"> 134 <NuxtLink 135 v-if="packageName" 136 :to="packageRoute(packageName)" 137 class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate" 138 > 139 {{ packageName }} 140 </NuxtLink> 141 <VersionSelector 142 v-if="resolvedVersion && pkg?.versions && pkg?.['dist-tags']" 143 :package-name="packageName" 144 :current-version="resolvedVersion" 145 :versions="pkg.versions" 146 :dist-tags="pkg['dist-tags']" 147 :url-pattern="`/package-docs/${packageName}/v/{version}`" 148 /> 149 <span v-else-if="resolvedVersion" class="text-fg-subtle font-mono text-sm shrink-0"> 150 {{ resolvedVersion }} 151 </span> 152 </div> 153 <div class="flex items-center gap-3 shrink-0"> 154 <span class="text-xs px-2 py-1 rounded badge-green border border-badge-green/50"> 155 API Docs 156 </span> 157 </div> 158 </div> 159 </div> 160 </header> 161 162 <div class="flex" dir="ltr"> 163 <!-- Sidebar TOC --> 164 <aside 165 v-if="docsData?.toc && !showEmptyState" 166 class="hidden lg:block w-64 xl:w-72 shrink-0 border-ie border-border" 167 > 168 <div class="docs-sidebar sticky overflow-y-auto p-4"> 169 <h2 class="text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-4"> 170 Contents 171 </h2> 172 <!-- eslint-disable vue/no-v-html --> 173 <div class="toc-content" v-html="docsData.toc" /> 174 </div> 175 </aside> 176 177 <!-- Main content --> 178 <main class="flex-1 min-w-0"> 179 <div v-if="showLoading" class="p-6 sm:p-8 lg:p-12 space-y-4"> 180 <SkeletonBlock class="h-8 w-64 rounded" /> 181 <SkeletonBlock class="h-4 w-full max-w-2xl rounded" /> 182 <SkeletonBlock class="h-4 w-5/6 max-w-2xl rounded" /> 183 <SkeletonBlock class="h-4 w-3/4 max-w-2xl rounded" /> 184 </div> 185 186 <div v-else-if="showEmptyState" class="p-6 sm:p-8 lg:p-12"> 187 <div class="max-w-xl rounded-lg border border-border bg-bg-muted p-6"> 188 <h2 class="font-mono text-lg mb-2">{{ $t('package.docs.not_available') }}</h2> 189 <p class="text-fg-subtle text-sm"> 190 {{ docsData?.message ?? $t('package.docs.not_available_detail') }} 191 </p> 192 <div class="flex gap-4 mt-4"> 193 <NuxtLink 194 v-if="packageName" 195 :to="packageRoute(packageName)" 196 class="link-subtle font-mono text-sm" 197 > 198 View package 199 </NuxtLink> 200 </div> 201 </div> 202 </div> 203 204 <!-- eslint-disable vue/no-v-html --> 205 <div v-else class="docs-content p-6 sm:p-8 lg:p-12" v-html="docsData?.html" /> 206 </main> 207 </div> 208 </div> 209</template> 210 211<style> 212/* Layout constants - must match AppHeader height */ 213.docs-page { 214 --app-header-height: 57px; 215 --docs-header-height: 57px; 216 --combined-header-height: calc(var(--app-header-height) + var(--docs-header-height)); 217} 218 219.docs-header { 220 top: var(--app-header-height); 221} 222 223.docs-sidebar { 224 top: var(--combined-header-height); 225 height: calc(100vh - var(--combined-header-height)); 226} 227 228/* Table of contents styles */ 229.toc-content ul { 230 @apply space-y-1; 231} 232 233.toc-content > ul > li { 234 @apply mb-4; 235} 236 237.toc-content > ul > li > a { 238 @apply text-sm font-medium text-fg-muted hover:text-fg; 239} 240 241.toc-content > ul > li > ul { 242 @apply mt-2 ps-3 border-is border-border/50; 243} 244 245.toc-content > ul > li > ul a { 246 @apply text-xs text-fg-subtle hover:text-fg block py-0.5 truncate; 247} 248 249/* Main docs content container - no max-width to use full space */ 250.docs-content { 251 @apply max-w-none; 252} 253 254/* Section headings */ 255.docs-content .docs-section { 256 @apply mb-16; 257} 258 259.docs-content .docs-section-title { 260 @apply text-lg font-semibold text-fg mb-8 pb-3 pt-4 border-b border-border sticky bg-bg z-[2]; 261 top: var(--combined-header-height); 262} 263 264/* Individual symbol articles */ 265.docs-content .docs-symbol { 266 @apply mb-10 pb-10 border-b border-border/30 last:border-0; 267} 268 269.docs-content .docs-symbol:target { 270 @apply scroll-mt-32; 271} 272 273.docs-content .docs-symbol:target .docs-symbol-header { 274 @apply bg-badge-yellow/10 -mx-3 px-3 py-1 rounded-md; 275} 276 277/* Symbol header (name + badges) */ 278.docs-content .docs-symbol-header { 279 @apply flex items-center gap-3 mb-4 flex-wrap; 280} 281 282.docs-content .docs-anchor { 283 @apply text-fg-subtle/50 hover:text-fg-subtle transition-colors text-lg no-underline; 284} 285 286.docs-content .docs-symbol-name { 287 @apply font-mono text-lg font-semibold text-fg m-0; 288} 289 290/* Badges */ 291.docs-content .docs-badge { 292 @apply text-xs px-2 py-0.5 rounded-full font-medium; 293} 294 295.docs-content .docs-badge--function { 296 @apply badge-blue; 297} 298.docs-content .docs-badge--class { 299 @apply badge-yellow; 300} 301.docs-content .docs-badge--interface { 302 @apply badge-green; 303} 304.docs-content .docs-badge--typeAlias { 305 @apply badge-indigo; 306} 307.docs-content .docs-badge--variable { 308 @apply badge-orange; 309} 310.docs-content .docs-badge--enum { 311 @apply badge-pink; 312} 313.docs-content .docs-badge--namespace { 314 @apply badge-cyan; 315} 316.docs-content .docs-badge--async { 317 @apply badge-purple; 318} 319 320/* Signature code block - now uses Shiki */ 321.docs-content .docs-signature { 322 @apply mb-5; 323} 324 325.docs-content .docs-signature .shiki { 326 @apply text-sm bg-bg-muted/50 border border-border/50 p-4 rounded-lg; 327 white-space: pre-wrap; 328 word-break: break-word; 329} 330 331.docs-content .docs-signature .shiki code { 332 @apply text-sm; 333 white-space: pre-wrap; 334} 335 336/* Overload count badge */ 337.docs-content .docs-overload-count { 338 @apply text-xs text-fg-subtle; 339} 340 341/* More overloads indicator */ 342.docs-content .docs-more-overloads { 343 @apply text-xs text-fg-subtle italic mt-2 mb-0; 344} 345 346/* Description text */ 347.docs-content .docs-description { 348 @apply text-sm text-fg-muted leading-relaxed mb-5; 349} 350 351/* Inline code in descriptions */ 352.docs-content .docs-description code { 353 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 354} 355 356/* 357 * Fenced code blocks in descriptions use a subtle start-border style. 358 * 359 * Design rationale: We use two visual styles for code examples: 360 * 1. Boxed style (bg + border + padding) - for formal @example JSDoc tags 361 * and function signatures. These are intentional, structured sections. 362 * 2. Start-border style (blockquote-like) - for inline code in descriptions. 363 * These are illustrative/casual and shouldn't compete with the signature. 364 */ 365.docs-content .docs-description .shiki { 366 @apply text-sm ps-4 py-3 my-4 border-is-2 border-border; 367 white-space: pre-wrap; 368 word-break: break-word; 369} 370 371.docs-content .docs-description .shiki code { 372 @apply text-sm bg-transparent p-0; 373 white-space: pre-wrap; 374} 375 376/* Deprecation warning */ 377.docs-content .docs-deprecated { 378 @apply bg-badge-orange/20 border border-badge-orange rounded-lg p-4 mb-5; 379} 380 381.docs-content .docs-deprecated strong { 382 @apply text-badge-orange text-sm; 383} 384 385.docs-content .docs-deprecated-message { 386 @apply text-badge-orange text-sm mt-2; 387} 388 389.docs-content .docs-deprecated-message code { 390 @apply bg-badge-orange/20 text-badge-orange; 391} 392 393.docs-content .docs-deprecated-message .docs-link { 394 @apply text-badge-orange; 395} 396 397/* Parameters, Returns, Examples, See Also sections */ 398.docs-content .docs-params, 399.docs-content .docs-returns, 400.docs-content .docs-examples, 401.docs-content .docs-see, 402.docs-content .docs-members { 403 @apply mb-5; 404} 405 406.docs-content .docs-params h4, 407.docs-content .docs-returns h4, 408.docs-content .docs-examples h4, 409.docs-content .docs-see h4, 410.docs-content .docs-members h4 { 411 @apply text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-3; 412} 413 414/* Definition lists for params/members */ 415.docs-content dl { 416 @apply space-y-2; 417} 418 419.docs-content dt { 420 @apply font-mono text-sm text-fg-muted; 421} 422 423.docs-content dd { 424 @apply text-sm text-fg-subtle ms-4 mb-3; 425} 426 427/* Returns paragraph */ 428.docs-content .docs-returns p { 429 @apply text-sm text-fg-muted m-0; 430} 431 432/* Example code blocks from @example JSDoc tags - boxed style (see design rationale above) */ 433.docs-content .docs-examples .shiki { 434 @apply text-sm bg-bg-muted border border-border/50 p-4 rounded-lg overflow-x-auto mb-3; 435} 436 437.docs-content .docs-examples .shiki code { 438 @apply text-sm; 439} 440 441/* See also list */ 442.docs-content .docs-see ul { 443 @apply list-disc list-inside text-sm text-fg-muted space-y-1; 444} 445 446.docs-content .docs-link { 447 @apply text-badge-blue hover:text-badge-blue/80 underline underline-offset-2; 448} 449 450/* Symbol cross-reference links */ 451.docs-content .docs-symbol-link { 452 @apply text-badge-green hover:text-badge-green/80 underline underline-offset-2; 453} 454 455/* Unknown symbol references shown as code */ 456.docs-content .docs-symbol-ref { 457 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 458} 459 460/* Inline code in descriptions */ 461.docs-content .docs-inline-code { 462 @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 463} 464 465/* Enum members */ 466.docs-content .docs-enum-members { 467 @apply flex flex-wrap gap-2 list-none p-0; 468} 469 470.docs-content .docs-enum-members li { 471 @apply m-0; 472} 473 474.docs-content .docs-enum-members code { 475 @apply text-sm font-mono text-fg-muted bg-bg-muted px-2 py-1 rounded; 476} 477 478/* Members section (constructors, properties, methods) */ 479.docs-content .docs-members pre { 480 @apply text-sm bg-bg-muted/50 border border-border/50 p-3 rounded-lg overflow-x-auto font-mono; 481} 482 483.docs-content .docs-members pre code { 484 @apply text-fg-muted; 485} 486 487.docs-content .docs-symbol-name, 488.docs-content .docs-members dl dd, 489.docs-content .docs-members dl dt code, 490.docs-content .docs-section .docs-symbol .docs-description { 491 word-break: break-all; 492} 493</style>